xtask\tasks\fmt/
unused_deps.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Check for unused Rust dependencies
5//!
6//! Forked from <https://github.com/bnjbvr/cargo-machete>
7//! (license copied in source)
8
9// Copyright (c) 2022 Benjamin Bouvier
10//
11// Permission is hereby granted, free of charge, to any
12// person obtaining a copy of this software and associated
13// documentation files (the "Software"), to deal in the
14// Software without restriction, including without
15// limitation the rights to use, copy, modify, merge,
16// publish, distribute, sublicense, and/or sell copies of
17// the Software, and to permit persons to whom the Software
18// is furnished to do so, subject to the following
19// conditions:
20//
21// The above copyright notice and this permission notice
22// shall be included in all copies or substantial portions
23// of the Software.
24//
25// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
26// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
27// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
28// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
29// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
30// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
31// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
32// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
33// DEALINGS IN THE SOFTWARE.
34
35use crate::Xtask;
36use anyhow::Context;
37use clap::Parser;
38use grep_regex::RegexMatcher;
39use grep_regex::RegexMatcherBuilder;
40use grep_searcher::BinaryDetection;
41use grep_searcher::Searcher;
42use grep_searcher::SearcherBuilder;
43use grep_searcher::Sink;
44use grep_searcher::SinkMatch;
45use rayon::prelude::*;
46use std::error;
47use std::path::Path;
48use std::path::PathBuf;
49use std::str::FromStr;
50
51#[derive(Parser)]
52#[clap(about = "Detect any unused dependencies in Cargo.toml files")]
53#[clap(after_help = r#"NOTE:
54
55    False-positives can be suppressed by setting `package.metadata.xtask.unused-dep.ignored`
56    in the corresponding `Cargo.toml` file.
57
58    For example, "test-env-log" has implicit deps on both "env_logger" and "tracing-subscriber":
59
60        [package.metadata.xtask.unused-deps]
61        ignored = ["env_logger", "tracing-subscriber"]
62"#)]
63pub struct UnusedDeps {
64    /// Attempt to remove any unused dependencies from Cargo.toml files.
65    #[clap(long)]
66    pub fix: bool,
67}
68
69impl Xtask for UnusedDeps {
70    fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
71        // Find directory entries.
72        let entries = ignore::Walk::new(&ctx.root)
73            .filter_map(|entry| match entry {
74                Ok(entry) => {
75                    if entry.file_name() == "Cargo.toml" {
76                        Some(entry.into_path())
77                    } else {
78                        None
79                    }
80                }
81                Err(err) => {
82                    log::error!("error when walking over subdirectories: {}", err);
83                    None
84                }
85            })
86            .collect::<Vec<_>>();
87
88        // Run analysis in parallel. This will spawn new rayon tasks when dependencies are effectively
89        // used by any Rust crate.
90        let mut results = entries
91            .par_iter()
92            .filter_map(|path| match analyze_crate(path) {
93                Ok(Some(analysis)) => Some((analysis, path)),
94
95                Ok(None) => {
96                    log::debug!("{} is a virtual manifest for a workspace", path.display());
97                    None
98                }
99
100                Err(err) => {
101                    log::error!("error when handling {}: {}", path.display(), err);
102                    None
103                }
104            })
105            .collect::<Vec<_>>();
106
107        results.sort_by(|a, b| a.1.cmp(b.1));
108
109        let mut workspace = analyze_workspace(&ctx.root)?;
110        let full_deps = workspace.deps.clone();
111
112        // Display all the results.
113
114        let mut found_something = false;
115        for (analysis, path) in results {
116            if !analysis.results.is_empty() {
117                found_something = true;
118                println!("{} -- {}:", analysis.package_name, path.display());
119                for result in &analysis.results {
120                    match result {
121                        DepResult::Unused(n) => println!("\t{} is unused", n),
122                        DepResult::IgnoredButUsed(n) => {
123                            println!("\t{} is ignored, but being used", n)
124                        }
125                        DepResult::IgnoredAndMissing(n) => {
126                            println!("\t{} is ignored, but it's not even being depended on", n)
127                        }
128                    }
129                }
130
131                if self.fix {
132                    let fixed =
133                        remove_dependencies(&fs_err::read_to_string(path)?, &analysis.results)?;
134                    fs_err::write(path, fixed).context("Cargo.toml write error")?;
135                }
136            }
137
138            workspace.deps.retain(|x| !analysis.deps.contains(x));
139        }
140
141        workspace.deps.sort();
142        workspace.ignored.sort();
143        if workspace.deps != workspace.ignored {
144            found_something = true;
145            let mut unused_deps = Vec::new();
146
147            println!("Workspace -- {}:", workspace.path.display());
148            for dep in &workspace.deps {
149                if !workspace.ignored.contains(dep) {
150                    println!("\t{} is unused", dep);
151                    unused_deps.push(DepResult::Unused(dep.clone()));
152                }
153            }
154            for ign in &workspace.ignored {
155                if !workspace.deps.contains(ign) {
156                    if full_deps.contains(ign) {
157                        println!("\t{} is ignored, but being used", ign);
158                        unused_deps.push(DepResult::IgnoredButUsed(ign.clone()));
159                    } else {
160                        println!("\t{} is ignored, but it's not even being depended on", ign);
161                        unused_deps.push(DepResult::IgnoredAndMissing(ign.clone()));
162                    }
163                }
164            }
165
166            if self.fix {
167                let fixed =
168                    remove_dependencies(&fs_err::read_to_string(&workspace.path)?, &unused_deps)?;
169                fs_err::write(&workspace.path, fixed).context("Cargo.toml write error")?;
170            }
171        }
172
173        if found_something && !self.fix {
174            Err(anyhow::anyhow!("found dependency issues"))
175        } else {
176            Ok(())
177        }
178    }
179}
180
181fn remove_dependencies(manifest: &str, analysis_results: &[DepResult]) -> anyhow::Result<String> {
182    let mut manifest = toml_edit::DocumentMut::from_str(manifest)?;
183
184    let mut unused_deps = Vec::new();
185    let mut ignored_and_shouldnt_be = Vec::new();
186
187    for res in analysis_results {
188        match res {
189            DepResult::Unused(n) => unused_deps.push(n),
190            DepResult::IgnoredButUsed(n) => ignored_and_shouldnt_be.push(n),
191            DepResult::IgnoredAndMissing(n) => ignored_and_shouldnt_be.push(n),
192        }
193    }
194
195    let mut features_table = None;
196    let mut dep_tables = Vec::new();
197    let mut ignored_array = None;
198    for (k, v) in manifest.iter_mut() {
199        let v = match v {
200            v if v.is_table_like() => v.as_table_like_mut().unwrap(),
201            _ => continue,
202        };
203
204        match k.get() {
205            "dependencies" | "build-dependencies" | "dev-dependencies" => dep_tables.push(v),
206            "target" => {
207                let flattened = v.iter_mut().flat_map(|(_, v)| {
208                    v.as_table_like_mut()
209                        .expect("conforms to cargo schema")
210                        .iter_mut()
211                });
212
213                for (k, v) in flattened {
214                    let v = match v {
215                        v if v.is_table_like() => v.as_table_like_mut().unwrap(),
216                        _ => continue,
217                    };
218
219                    match k.get() {
220                        "dependencies" | "build-dependencies" | "dev-dependencies" => {
221                            dep_tables.push(v)
222                        }
223                        _ => {}
224                    }
225                }
226            }
227            "workspace" => {
228                for (k2, v2) in v.iter_mut() {
229                    let v2 = match v2 {
230                        v2 if v2.is_table_like() => v2.as_table_like_mut().unwrap(),
231                        _ => continue,
232                    };
233
234                    match k2.get() {
235                        "dependencies" => dep_tables.push(v2),
236                        "metadata" => {
237                            // get_mut() seems to create a new table that wasn't previously
238                            // there in some cases, so first check with the immutable
239                            // accessors.
240                            if v2
241                                .get("xtask")
242                                .and_then(|x| x.get("unused-deps"))
243                                .and_then(|u| u.get("ignored"))
244                                .is_some()
245                            {
246                                ignored_array = v2
247                                    .get_mut("metadata")
248                                    .unwrap()
249                                    .get_mut("xtask")
250                                    .unwrap()
251                                    .get_mut("unused-deps")
252                                    .unwrap()
253                                    .get_mut("ignored")
254                                    .unwrap()
255                                    .as_array_mut();
256                            }
257                        }
258                        _ => {}
259                    }
260                }
261            }
262            "package" => {
263                // get_mut() seems to create a new table that wasn't previously
264                // there in some cases, so first check with the immutable
265                // accessors.
266                if v.get("metadata")
267                    .and_then(|m| m.get("xtask"))
268                    .and_then(|x| x.get("unused-deps"))
269                    .and_then(|u| u.get("ignored"))
270                    .is_some()
271                {
272                    ignored_array = v
273                        .get_mut("metadata")
274                        .unwrap()
275                        .get_mut("xtask")
276                        .unwrap()
277                        .get_mut("unused-deps")
278                        .unwrap()
279                        .get_mut("ignored")
280                        .unwrap()
281                        .as_array_mut();
282                }
283            }
284            "features" => features_table = Some(v),
285            _ => {}
286        }
287    }
288
289    for i in ignored_and_shouldnt_be {
290        let ignored_array = ignored_array
291            .as_mut()
292            .expect("must have an ignored array for IgnoredButUsed results to appear");
293        let index = ignored_array
294            .iter()
295            .position(|v| v.as_str() == Some(i))
296            .expect("must find items that were found in previous pass");
297        ignored_array.remove(index);
298    }
299
300    if let Some(features_table) = features_table {
301        for (_feature_name, feature_deps) in features_table.iter_mut() {
302            let mut to_remove = Vec::new();
303            let feature_deps = feature_deps
304                .as_array_mut()
305                .expect("feature dependencies must be an array");
306            for index in 0..feature_deps.len() {
307                let feature_dep_name = feature_deps
308                    .get(index)
309                    .unwrap()
310                    .as_str()
311                    .expect("feature dependencies must be strings");
312                let feature_dep_name = feature_dep_name
313                    .strip_prefix("dep:")
314                    .unwrap_or(feature_dep_name);
315                for unused in &unused_deps {
316                    if feature_dep_name.starts_with(&**unused)
317                        && (feature_dep_name.len() == unused.len()
318                            || matches!(feature_dep_name.as_bytes()[unused.len()], b'/' | b'?'))
319                    {
320                        to_remove.push(index);
321                    }
322                }
323            }
324            for i in to_remove.into_iter().rev() {
325                feature_deps.remove(i);
326            }
327        }
328    }
329
330    for dep_table in dep_tables {
331        unused_deps.retain(|dep| dep_table.remove(dep).is_none());
332    }
333    assert!(unused_deps.is_empty());
334
335    let serialized = manifest.to_string();
336    Ok(serialized)
337}
338
339mod meta {
340    use serde::Deserialize;
341    use serde::Serialize;
342
343    #[derive(Serialize, Deserialize)]
344    pub struct PackageMetadata {
345        pub xtask: Option<Xtask>,
346    }
347    #[derive(Serialize, Deserialize)]
348    pub struct Xtask {
349        #[serde(rename = "unused-deps")]
350        pub unused_deps: Option<Ignored>,
351    }
352
353    #[derive(Serialize, Deserialize)]
354    pub struct Ignored {
355        pub ignored: Vec<String>,
356    }
357}
358
359type Manifest = cargo_toml::Manifest<meta::PackageMetadata>;
360
361struct PackageAnalysis {
362    pub package_name: String,
363    pub results: Vec<DepResult>,
364    pub deps: Vec<String>,
365}
366
367#[derive(PartialEq, Eq, PartialOrd, Ord)]
368enum DepResult {
369    /// Dependency is unused and not marked as ignored.
370    Unused(String),
371    /// Dependency is marked as ignored but used.
372    IgnoredButUsed(String),
373    /// Dependency is marked as ignored but not being depended on.
374    IgnoredAndMissing(String),
375}
376
377struct WorkspaceAnalysis {
378    pub path: PathBuf,
379    pub deps: Vec<String>,
380    pub ignored: Vec<String>,
381}
382
383fn make_regexp(name: &str) -> String {
384    // Breaking down this regular expression: given a line,
385    // - `use (::)?{name}(::|;| as)`: matches `use foo;`, `use foo::bar`, `use foo as bar;`, with
386    // an optional "::" in front of the crate's name.
387    // - `\b({name})::`: matches `foo::X`, but not `barfoo::X`. `\b` means word boundary, so
388    // putting it before the crate's name ensures there's no polluting prefix.
389    // - `extern crate {name}( |;)`: matches `extern crate foo`, or `extern crate foo as bar`.
390    format!(r#"use (::)?{name}(::|;| as)|\b{name}::|extern crate {name}( |;)"#)
391}
392
393/// Returns all the paths to the Rust source files for a crate contained at the given path.
394fn collect_paths(dir_path: &Path, manifest: &Manifest) -> Vec<PathBuf> {
395    let mut root_paths = Vec::new();
396
397    if let Some(path) = manifest.lib.as_ref().and_then(|lib| lib.path.as_ref()) {
398        assert!(
399            path.ends_with(".rs"),
400            "paths provided by cargo_toml are to Rust files"
401        );
402        let mut path_buf = PathBuf::from(path);
403        // Remove .rs extension.
404        path_buf.pop();
405        root_paths.push(path_buf);
406    }
407
408    for product in (manifest.bin.iter())
409        .chain(manifest.bench.iter())
410        .chain(manifest.test.iter())
411        .chain(manifest.example.iter())
412    {
413        if let Some(ref path) = product.path {
414            assert!(
415                path.ends_with(".rs"),
416                "paths provided by cargo_toml are to Rust files"
417            );
418            let mut path_buf = PathBuf::from(path);
419            // Remove .rs extension.
420            path_buf.pop();
421            root_paths.push(path_buf);
422        }
423    }
424
425    log::trace!("found root paths: {:?}", root_paths);
426
427    if root_paths.is_empty() {
428        // Assume "src/" if cargo_toml didn't find anything.
429        root_paths.push(PathBuf::from("src"));
430        log::trace!("adding src/ since paths was empty");
431    }
432
433    // Collect all final paths for the crate first.
434    let mut paths: Vec<PathBuf> = root_paths
435        .iter()
436        .flat_map(|root| ignore::Walk::new(dir_path.join(root)))
437        .filter_map(|result| {
438            let dir_entry = match result {
439                Ok(dir_entry) => dir_entry,
440                Err(err) => {
441                    log::error!("{}", err);
442                    return None;
443                }
444            };
445
446            if !dir_entry.file_type().unwrap().is_file() {
447                return None;
448            }
449
450            if dir_entry
451                .path()
452                .extension()
453                .is_none_or(|ext| ext.to_str() != Some("rs"))
454            {
455                return None;
456            }
457
458            Some(dir_entry.path().to_owned())
459        })
460        .collect();
461
462    let build_rs = dir_path.join("build.rs");
463    if build_rs.exists() {
464        paths.push(build_rs);
465    }
466
467    log::trace!("found transitive paths: {:?}", paths);
468
469    paths
470}
471
472struct Search {
473    matcher: RegexMatcher,
474    searcher: Searcher,
475    sink: StopAfterFirstMatch,
476}
477
478impl Search {
479    fn new(crate_name: &str) -> anyhow::Result<Self> {
480        let snaked = crate_name.replace('-', "_");
481        let pattern = make_regexp(&snaked);
482        let matcher = RegexMatcherBuilder::new()
483            .multi_line(true)
484            .build(&pattern)?;
485
486        let searcher = SearcherBuilder::new()
487            .binary_detection(BinaryDetection::quit(b'\x00'))
488            .line_number(false)
489            .build();
490
491        let sink = StopAfterFirstMatch::new();
492
493        Ok(Self {
494            matcher,
495            searcher,
496            sink,
497        })
498    }
499
500    fn search_path(&mut self, path: &Path) -> anyhow::Result<bool> {
501        self.searcher
502            .search_path(&self.matcher, path, &mut self.sink)
503            .map_err(|err| anyhow::anyhow!("when searching: {}", err))
504            .map(|_| self.sink.found)
505    }
506}
507
508fn analyze_workspace(root: &Path) -> anyhow::Result<WorkspaceAnalysis> {
509    let path = root.join("Cargo.toml");
510    let manifest = Manifest::from_path_with_metadata(&path)?;
511    let workspace = manifest
512        .workspace
513        .expect("workspace manifest must have a workspace section");
514
515    let deps = workspace.dependencies.into_keys().collect();
516
517    let ignored = workspace
518        .metadata
519        .and_then(|meta| meta.xtask.and_then(|x| x.unused_deps.map(|u| u.ignored)))
520        .unwrap_or_default();
521
522    Ok(WorkspaceAnalysis {
523        deps,
524        path,
525        ignored,
526    })
527}
528
529fn analyze_crate(manifest_path: &Path) -> anyhow::Result<Option<PackageAnalysis>> {
530    let mut dir_path = manifest_path.to_path_buf();
531    dir_path.pop();
532
533    log::trace!("trying to open {}...", manifest_path.display());
534
535    let mut manifest = Manifest::from_path_with_metadata(manifest_path)?;
536    let package_name = match manifest.package {
537        Some(ref package) => package.name.clone(),
538        None => return Ok(None),
539    };
540
541    log::debug!("handling {} ({})", package_name, dir_path.display());
542
543    manifest.complete_from_path(manifest_path)?;
544
545    let paths = collect_paths(&dir_path, &manifest);
546
547    let mut deps = Vec::new();
548
549    deps.extend(manifest.dependencies.keys().cloned());
550    deps.extend(manifest.build_dependencies.keys().cloned());
551    deps.extend(manifest.dev_dependencies.keys().cloned());
552    for target in manifest.target.iter() {
553        deps.extend(target.1.dependencies.keys().cloned());
554        deps.extend(target.1.build_dependencies.keys().cloned());
555        deps.extend(target.1.dev_dependencies.keys().cloned());
556    }
557
558    let ignored = if let Some(unused_deps) = manifest
559        .package
560        .and_then(|package| package.metadata)
561        .and_then(|meta| meta.xtask.and_then(|x| x.unused_deps))
562    {
563        unused_deps.ignored
564    } else {
565        Vec::new()
566    };
567
568    let mut results = deps
569        .par_iter()
570        .filter_map(|name| {
571            let mut search = Search::new(name).expect("constructing grep context");
572
573            let mut found_once = false;
574            for path in &paths {
575                log::trace!("looking for {} in {}", name, path.to_string_lossy());
576                match search.search_path(path) {
577                    Ok(true) => {
578                        found_once = true;
579                        break;
580                    }
581                    Ok(false) => {}
582                    Err(err) => {
583                        log::error!("{}: {}", path.display(), err);
584                    }
585                };
586            }
587
588            let ignored = ignored.contains(name);
589
590            match (found_once, ignored) {
591                (true, true) => Some(DepResult::IgnoredButUsed(name.into())),
592                (true, false) => None,
593                (false, true) => None,
594                (false, false) => Some(DepResult::Unused(name.into())),
595            }
596        })
597        .collect::<Vec<_>>();
598
599    for i in &ignored {
600        if !deps.contains(i) {
601            results.push(DepResult::IgnoredAndMissing(i.clone()));
602        }
603    }
604
605    results.sort();
606
607    Ok(Some(PackageAnalysis {
608        package_name,
609        results,
610        deps,
611    }))
612}
613
614struct StopAfterFirstMatch {
615    found: bool,
616}
617
618impl StopAfterFirstMatch {
619    fn new() -> Self {
620        Self { found: false }
621    }
622}
623
624impl Sink for StopAfterFirstMatch {
625    type Error = Box<dyn error::Error>;
626
627    fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
628        let mat = String::from_utf8(mat.bytes().to_vec())?;
629        let mat = mat.trim();
630
631        if mat.starts_with("//") || mat.starts_with("//!") {
632            // Continue if seeing what resembles a comment or doc comment. Unfortunately we can't
633            // do anything better because trying to figure whether we're within a (doc) comment
634            // would require actual parsing of the Rust code.
635            return Ok(true);
636        }
637
638        // Otherwise, we've found it: mark to true, and return false to indicate that we can stop
639        // searching.
640        self.found = true;
641        Ok(false)
642    }
643}